package nayasis.common.db.sql.impl;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import nayasis.common.db.common.DbCommon;
import nayasis.common.db.manager.connection.ConnectionManager;
import nayasis.common.db.manager.connection.WrapperConnection;
import nayasis.common.db.manager.sql.ParamValue;
import nayasis.common.db.manager.sql.SqlManager;
import nayasis.common.db.manager.sql.SqlMap;
import nayasis.common.db.manager.transaction.TxManager;
import nayasis.common.db.sql.Statement;
import nayasis.common.exception.unchecked.DatabaseException;
import nayasis.common.log.NLogger;
import nayasis.common.message.Message;
import nayasis.common.model.NData;
import nayasis.common.model.NMultiData;
import nayasis.common.model.NVo;
import nayasis.common.util.ReflectionUtil;
import nayasis.common.util.StringUtil;

/**
 * Statement 구현체
 *
 * @author nayasis
 */
public class NPreparedStatement implements Statement {

	private enum CommentStatus { NONE, START_BLOCK, START_LINE, START_QUOT }

	/**
	 * 바인딩 파라미터 정보가 저장되는 목록
	 */
	private Map<String, ParamValue> bindingParam;

	/**
	 * 실제 바인딩을 수행할 파라미터 정보가 저장되는 목록
	 */
	private List<ParamValue> bindingValue;

	/**
	 * 사용자가 입력한 기준 SQL
	 */
	private String sql = "";

	// DB Spec
	private String dbSpec = null;

	/**
	 * 기본생성자
	 *
	 * @param sql 실행할 SQL
	 */
	public NPreparedStatement( String sql ) {
		setSql( sql );
	}

	/**
	 * 기본생성자
	 */
	public NPreparedStatement() {
	}

	/**
	 * <pre>
	 * SQL 문장에서 바인딩 파라미터의 위치를 뽑아낸다.
	 *
	 * :* 패턴의 바인딩 파라미터는 ? 문자로 변환하고 그 명칭을 기준정보로 저장해둔다.
	 * </pre>
	 *
	 * @param sql
	 * @return 바인딩 파라미터로 ? 문자만을 갖는 SQL 문장
	 */
	private String constructSql( String sql ) {

	    // 기존에 세팅되었던 bindingValue 초기화
	    bindingValue.clear();

		StringBuffer query = new StringBuffer( sql );

		int index = -1;

		while( true ) {

		    // 바인딩 파라미터의 시작위치를 찾는다.
			index = findBindingParam( index + 1, query );

			if( index == -1 ) break;

			// 바인딩 파라미터의 이름을 찾는다.
			String paramKey = findParamName( query, index );

			// 파라미터 이름이 세팅된 값이라면, SQL에서 사용하는 바인딩변수(?) 로 변경한다.

			if( bindingParam.containsKey(paramKey) ) {

			    ParamValue paramValue = bindingParam.get( paramKey );

	            // IN 절로 구성할 배열용 파라미터라면
	            if( paramValue.isArray() ) {

	                List<?> paramValues = (List<?>) paramValue.getValue();
	                int     paranCnt    = paramValues.size();

	                StringBuffer arrayParam = new StringBuffer();

	                for( int i = 0; i < paranCnt; i++ ) {

	                    arrayParam.append( '?' );

	                    if( i < paranCnt - 1 ) {
	                        arrayParam.append( ',' );
	                    }

	                    bindingValue.add( new ParamValue(paramValues.get(i)) );

	                }

	                query.replace( index, index + paramKey.length() + 1, arrayParam.toString() );

	            // 일반적인 파라미터라면
	            } else {

	                bindingValue.add( paramValue );

	                query.replace( index, index + paramKey.length() + 1, "?" );

	            }


			}

		}

		return query.toString();

	}

	private String findParamName( StringBuffer sql, int searchStartIndex ) {

        StringBuffer sbParamName = new StringBuffer();

        while(true) {

            char nextC = sql.charAt( ++searchStartIndex );

            if( DbCommon.isChar(nextC) ) {
                sbParamName.append( nextC );
            } else {
                break;
            }

        }

        return sbParamName.toString();

	}

	/**
	 * SQL 문장에서 :* 패턴의 문자열이 시작하는 위치를 검색한다.
	 *
	 * @param searchStartIndex 검색 시작위치
	 * @param sql 검사할 SQL 문장
	 * @param findQuestionMark :*패턴의 문자열을 찾고자 한다면 false, ? 문자열을 찾고자 한다면 true
	 * @return 바인딩 파라미터 시작위치
	 */
	private int findBindingParam( int searchStartIndex, StringBuffer sql ) {

		CommentStatus commentStatus = CommentStatus.NONE;

		for( int i = searchStartIndex, iCnt = sql.length() - 2; i < iCnt; i++ ) {

			char currC = sql.charAt( i );
			char nextC = sql.charAt( i + 1 );

			switch( commentStatus ) {

			    case NONE :

			        // '/*' 형식의 주석시작지점 검색
    				if( currC == '/' && nextC == '*' ) {
    					commentStatus = CommentStatus.START_BLOCK;
    					i += 2;

    				// '--' 형식의 주석시작지점 검색
    				} else if( currC == '-' && nextC == '-' ) {
    					commentStatus = CommentStatus.START_LINE;
    					i += 2;

    				// ' 형식의 문자열 세팅지점 검색
    				} else if( currC == '\'' ) {
    					commentStatus = CommentStatus.START_QUOT;

    				// :* 문자 검색
    				} else if( currC == ':' && DbCommon.isChar(nextC) ) {
    					return i;
    				}

    				break;

    			case START_BLOCK :

    				if( currC == '*' && nextC == '/' ) {
    					commentStatus = CommentStatus.NONE;
    					i += 2;
    				}

    				break;

    			case START_LINE :

    				if( currC == '\n' ) {
    					commentStatus = CommentStatus.NONE;
    					i += 2;
    				}

    				break;

    			case START_QUOT :

    				if( currC == '\'' ) {
    					commentStatus = CommentStatus.NONE;
    					i += 2;
    				}

    				break;

    			default:
    				break;

			}

		}

		return -1;

	}

	public NData executeQueryAsSingle() throws DatabaseException {

		NMultiData result = executeQuery();

		if( result.size() == 0 ) return new NData();

		return result.get( 0 );

	}

	public NMultiData executeQuery() throws DatabaseException {

        WrapperConnection conn   = null;
        PreparedStatement pstmt  = null;
        ResultSet         rs     = null;

        NMultiData        result = new NMultiData();

        try {

            conn = ConnectionManager.getConnection( dbSpec );

            pstmt = getExecutablePreparedStatement( conn.getRealConnection() );

            rs = pstmt.executeQuery();

            result = convertResultSet( rs );

        } catch (SQLException e) {

        	DatabaseException de = new DatabaseException( e, e.getMessage() );

        	de.setErrorCode( e.getErrorCode() );

            throw de;

        } finally {

            close( rs );
            close( pstmt );

            ConnectionManager.closeConnection(  conn );

        }

        return result;

	}

	private NMultiData convertResultSet( ResultSet rs ) throws SQLException {

		NMultiData result = new NMultiData();

	    List<String> columnLabels = getColumnLabels( rs );

	    for( String column : columnLabels ) {
	        result.addHeader( StringUtil.getCamel(column) );
	    }

	    while( rs.next() ) {

	        for( String column : columnLabels ) {
	            result.add( StringUtil.getCamel(column), rs.getString(column) );
	        }

	    }

	    return result;

	}

	/**
	 * ResultSet에서 컬럼명을 추출한다.
	 *
	 * @param rs 조회결과
	 * @return 컬럼명
	 * @throws SQLException 중복된 컬럼명이 존재할 때, ResultSet 처리 오류시
	 */
	private List<String> getColumnLabels( ResultSet rs ) throws SQLException {

	    // ResultSet 에서 컬럼명을 추출

        ResultSetMetaData metaData = rs.getMetaData();

        int columnCnt = metaData.getColumnCount();

        List<String> columnLabels = new ArrayList<String>();

        for( int i = 1; i <= columnCnt; i++ ) {
            columnLabels.add( metaData.getColumnName(i) );
        }

        // 중복된 컬럼명이 존재하는지 여부를 검사

        Map<String, Integer> duplicatedColumnName = new HashMap<String, Integer>();

        for( int i = 0; i < columnCnt - 1; i++ ) {

            String nameSrc = columnLabels.get( i );

            for( int j = i + 1; j < columnCnt; j++ ) {

                String nameTrg = columnLabels.get( j );

                if( nameSrc.equals(nameTrg) ) {
                    duplicatedColumnName.put( nameSrc, 0 );
                }

            }

        }

        Set<String> keySet = duplicatedColumnName.keySet();

        if( keySet.size() > 0 ) {
            throw new SQLException( Message.get( "중복된 Column 명이 존재합니다. @", keySet.toString() ) );
        }

	    return columnLabels;
	}


	public int executeUpdate() throws DatabaseException {

        WrapperConnection conn   = null;
        PreparedStatement pstmt  = null;
        int               result = 0;

        TxManager.beginTransaction();

        try {

            conn = ConnectionManager.getConnection( dbSpec );

            pstmt = getExecutablePreparedStatement( conn.getRealConnection() );

            result = pstmt.executeUpdate();

        } catch (SQLException e) {

        	DatabaseException de = new DatabaseException( e, e.getMessage() );
        	de.setErrorCode( e.getErrorCode() );

            throw de;

        } finally {

            close( pstmt );
            ConnectionManager.closeConnection( conn );

        }

		return result;

	}

	private PreparedStatement getExecutablePreparedStatement( Connection conn ) throws SQLException {

        String sql = constructSql( this.sql );

        printSql( sql );

        PreparedStatement pstmt = conn.prepareStatement( sql );

        for( int i = 0, iCnt = bindingValue.size(); i < iCnt; i++ ) {

            ParamValue param = bindingValue.get( i );
            int        index = i + 1;

            switch( param.getType() ) {

                case Types.NULL :
                    pstmt.setNull( index, Types.NULL );
                    break;
                case Types.INTEGER :
                    pstmt.setLong( index, (Long) param.getValue() );
                    break;
                case Types.FLOAT :
                    pstmt.setFloat( index, (Float) param.getValue() );
                    break;
                case Types.DOUBLE :
                    pstmt.setDouble( index, (Double) param.getValue() );
                    break;
                case Types.VARCHAR :
                    pstmt.setString( index, (String) param.getValue() );
                    break;

            }

        }

        return pstmt;

	}

	/**
     * SQL 문장을 실행용 SQL 문장으로 변경한다.
     *
     * <pre>
     * 로그용 SQL 문장도 생성하여 출력
     * </pre>
     *
     * @param sql 변경하기 전의 SQL 문장
     * @return 실행용 SQL 문장
     * @throws SQLException 바인딩 위치를 잘못 지정할 경우
     */
    private void printSql( String sql ) throws SQLException {

        if( ! NLogger.db.isEnable() ) return;

        StringBuffer query = new StringBuffer( sql );

        int searchStartIndex  = 0;
        int bindingValueIndex = 0;

        while( true ) {

            searchStartIndex = findBindingParam( searchStartIndex, query );

            if( searchStartIndex == -1 ) break;

            searchStartIndex += bindValue( query, bindingValue.get( bindingValueIndex++ ), searchStartIndex );

        }

        NLogger.db.println( "--------------------------------------------------------" );
        NLogger.db.println( ">> SQL called at " + getCallerInfo() );
        NLogger.db.println( "--------------------------------------------------------" );
        NLogger.db.println( query );
        NLogger.db.println( "--------------------------------------------------------" );

    }

    private int bindValue( StringBuffer sql, ParamValue param, int replaceIndex ) {

        StringBuffer sb = new StringBuffer();

        if( param.isVarchar() ) {
            sb.append( '\'' ).append( param.getValue() ).append( '\'' );
        } else {
            sb.append( param.getValue() );
        }

        sql.replace( replaceIndex, replaceIndex + 1, sb.toString() );

        return sb.length();

    }

	private void close( ResultSet rs ) {

	    if( rs != null ) {
	        try {
                rs.close();
            } catch (SQLException e) {
                NLogger.sys.println( e );
            } finally {
                rs = null;
            }
	    }

	}

	private void close( PreparedStatement pstmt ) {

	    if( pstmt != null ) {

	        try {
	            pstmt.close();
	        } catch (SQLException e) {
	            NLogger.sys.println( e );
	        } finally {
	            pstmt = null;
	        }
	    }

	}

	public Statement setInt( String paramName, int value ) {

	    bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

	public Statement setLong( String paramName, long value ) {

        bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

	public Statement setFloat( String paramName, float value ) {

        bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

	public Statement setDouble( String paramName, double value ) {

        bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

	public Statement setString( String paramName, String value ) {

        bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

	public Statement setArray(String paramName, List<?> value) {

        bindingParam.put( paramName, new ParamValue(value) );

        return this;

	}

    public Statement setArray( String paramName, Object[] value ) {

        List<?> list = Arrays.asList( value );

        return setArray( paramName, list );

    }

    private void bingingParam( String paramName, Object value ) {

        bindingParam.put( paramName, new ParamValue(value) );

    }

	public Statement setEntity( NVo entity ) {

	    Map<String, Object> fieldMap = ReflectionUtil.getFieldMap( entity );

	    return setEntity( fieldMap );

	}

	public Statement setEntity( Map<String, ?> entity ) {

	    Set<String> keySet = entity.keySet();

	    for( String key : keySet ) {

	        Object value = entity.get( key );

	        bingingParam( key, value );

	    }

	    return this;

	}

    /**
     * 로그가 호출된 지점의 StackTrace 정보를 가져온다.
     *
     * @return StackTrace 정보
     */
    private StackTraceElement getStackTrace() {

        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();

        StackTraceElement stackForLogger = null;

        boolean watchStart = false;

        String thisClassName = this.getClass().getName();

        for( int i = 0, iCnt = stackTrace.length; i < iCnt; i++ ) {

            StackTraceElement stack = stackTrace[ i ];

            if( watchStart == false && ! thisClassName.equals(stack.getClassName()) ) {
                continue;

            } else if( watchStart == true && ! thisClassName.equals(stack.getClassName()) ) {
                stackForLogger = stack;
                break;

            } else {
                watchStart = true;
            }

        }

        return stackForLogger;

    }

    /**
     * StackTrace 정보에서 해당 객체를 호출한 지점정보를 뽑아온다.
     *
     * @return 소스정보
     */
    private StringBuffer getCallerInfo() {

        StringBuffer sb = new StringBuffer();

        StackTraceElement stackTrace = getStackTrace();

        sb.append( getClassName(stackTrace) );

        String fileName   = stackTrace.getFileName();
        int    lineNumber = stackTrace.getLineNumber();

        if( fileName != null && lineNumber >= 0 ) {
            sb.append( '(' );
            sb.append( fileName ).append( ':' ).append( lineNumber );
            sb.append( ')' );
        }

        return sb;

    }

    /**
     * StackTrace 정보에서 클래스 정보를 뽑아온다.
     *
     * @return 클래스정보
     */
    private StringBuffer getClassName( StackTraceElement stackTrace ) {

        StringBuffer sb = new StringBuffer();

        String className  = stackTrace.getClassName();
        String methodName = stackTrace.getMethodName();

        if( stackTrace.isNativeMethod() ) {
            sb.append( className );
            sb.append( '.' );
            sb.append( methodName );
            sb.append( "(Native Method)" );
        } else {
            sb.append( className.substring(className.lastIndexOf('.') + 1) );
            sb.append( '.' );
            sb.append( methodName );
        }

        return sb;

    }

	public Statement setSql( String sql ) {

		return setSql( sql, null );

	}

	public Statement setSql( String sql, String dbSpec ) {

		// init parameter
		this.bindingParam = new HashMap<String, ParamValue>();
		this.bindingValue = new ArrayList<ParamValue>();

		// Retrieve SQL from repository
		SqlMap map = SqlManager.getSql( sql, dbSpec );

		this.sql    = map.getSql();
		this.dbSpec = map.getDbSpec();

		return this;

	}

}

